Skip to content3. 整合:
概述
makeImmutable 函数需要返回一个深度不可变的“视图”——任何试图修改属性、删除属性、定义新属性或调用数组变异方法的行为都会被拦截并抛出错误。
JavaScript 内置的 Object.freeze 仅冻结对象的第一层。嵌套对象依然可以被修改。
js
const obj = Object.freeze({ a: { b: 1 } });
obj.a.b = 2; // 实际会修改成功,因为嵌套对象没有被冻结要实现深度不可变,可以选择递归冻结整个对象树,也可以借助 Proxy 构建一个按需介入的拦截层。后者的特点是不需要在入口处遍历所有嵌套对象 —— 每次访问到属性值时再决定是否返回新的代理。
基本概念
Proxy 的处理器(handler)通过一组陷阱(trap)接管对象的基本操作。对于不可变视图来说,至少需要拦截:
set—— 普通属性赋值,包括obj.prop = val和obj[prop] = valdeleteProperty——delete obj.propdefineProperty——Object.defineProperty或通过属性赋值触发setPrototypeOf——Object.setPrototypeOf或__proto__赋值
此外,数组的变异方法(push、pop、shift、unshift、splice、sort、reverse、copyWithin、fill)也需要拦截。
工作原理
1. 对象属性的拦截
get 陷阱不能简单返回 target[prop]。当属性值本身是对象时,需要对这个值再次调用 proxify,从而让嵌套对象也受到相同的不可变约束。对非对象类型直接返回原值即可。
ts
function isObject(val: unknown): val is Record<string, unknown> | unknown[] {
return typeof val === 'object' && val !== null;
}
function proxify<T extends Obj>(obj: T): T {
// 代理逻辑见后文
}set、deleteProperty、defineProperty 等陷阱均抛出错误,错误信息中包含被操作的属性名。
ts
set(target, prop) {
const err = Array.isArray(target)
? `Error Modifying Index: ${String(prop)}`
: `Error Modifying: ${String(prop)}`;
throw err;
},
deleteProperty(target, prop) {
throw `Error Deleting Property: ${String(prop)}`;
},
defineProperty(target, prop) {
throw `Error Defining Property: ${String(prop)}`;
},
setPrototypeOf() {
throw `Error Setting Prototype`;
}2. 数组变异方法的拦截
对于数组目标,get 陷阱在访问 push 等方法时需要返回一个会抛出错误的函数代理。这个函数代理由 methodHandler 提供,其中 apply 陷阱在任何调用时直接抛出错误。
ts
const MUTATING_METHODS = new Set([
'pop', 'push', 'shift', 'unshift', 'splice', 'sort', 'reverse',
'copyWithin', 'fill'
]);
const methodHandler: ProxyHandler<(...args: any[]) => any> = {
apply(target, thisArg, args) {
throw `Error Calling Method: ${target.name}`;
}
};这样原始数组的方法本身没有被修改 —— 拦截逻辑全部留在代理中。
3. 整合:proxify 的完整实现
ts
function proxify<T extends Obj>(obj: T): T {
return new Proxy(obj, {
get(target, prop) {
const val = Reflect.get(target, prop);
// 对数组变异方法返回抛错代理
if (
Array.isArray(target) &&
typeof prop === 'string' &&
MUTATING_METHODS.has(prop)
) {
return new Proxy(val, methodHandler);
}
if (isObject(val)) {
return proxify(val);
}
return val;
},
set(target, prop) {
const err = Array.isArray(target)
? `Error Modifying Index: ${String(prop)}`
: `Error Modifying: ${String(prop)}`;
throw err;
},
deleteProperty(target, prop) {
throw `Error Deleting Property: ${String(prop)}`;
},
defineProperty(target, prop) {
throw `Error Defining Property: ${String(prop)}`;
},
setPrototypeOf() {
throw `Error Setting Prototype`;
}
}) as T;
}入口函数 makeImmutable 直接委托给 proxify:
ts
function makeImmutable(obj: Obj): Obj {
return proxify(obj);
}类型定义:
ts
type JSONValue =
| null
| boolean
| number
| string
| JSONValue[]
| { [key: string]: JSONValue };
type Obj = Array<JSONValue> | Record<string, JSONValue>;示例
js
const obj = makeImmutable({ x: 5, y: [1, 2] });
obj.x = 10; // Throws: Error Modifying: x
delete obj.x; // Throws: Error Deleting Property: x
Object.defineProperty(obj, 'z', { value: 3 }); // Throws: Error Defining Property: z
obj.y.push(3); // Throws: Error Calling Method: push
obj.y[0] = 99; // Throws: Error Modifying Index: 0
obj.y.copyWithin(0, 1); // Throws: Error Calling Method: copyWithin所有修改操作均被拦截并抛出带有明确属性或方法名称的错误。
注意点
同一嵌套对象会生成多个代理
每次访问嵌套对象都会调用proxify并创建新的Proxy实例。因此:jsconst nested = makeImmutable({ a: { b: 1 } }); nested.a === nested.a; // false如果业务中依赖引用一致性,可以在
proxify内部通过WeakMap实现对相同目标返回同一个代理。不可变的是视图而不是数据
原始对象并未被冻结。如果外部有对原始对象的引用,绕过代理直接修改原始对象,视图不会察觉。这个设计的定位是提供一层“不可变访问”,而非底层数据的绝对冻结。函数属性会被代理
isObject判定会将函数视为对象,因此函数值也会被proxify包装。由于调用时不触发get之外的其他陷阱(除非额外拦截apply),常见做法是只对纯数据结构使用makeImmutable。
限制
- 通过原型链间接修改属性(如
obj.__proto__.x = 1)无法被拦截,除非同时代理原型对象。 Symbol属性名在错误信息中会被转为"Symbol(xxx)"这样的字符串描述。setPrototypeOf仅拦截Object.setPrototypeOf和直接设置__proto__,无法阻止Object.create在创建阶段建立的原型关联(因为那次操作发生在代理建立之前)。
